Skip to content

Skip synthesis of candidates whose MPR features or stem names fail - #570

Closed
johnml1135 wants to merge 1 commit into
masterfrom
perf/hc-lookup-replay-pr
Closed

johnml1135 wants to merge 1 commit into
masterfrom
perf/hc-lookup-replay-pr

Conversation

@johnml1135

@johnml1135 johnml1135 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Start here: src/SIL.Machine.Morphology.HermitCrab/LookupReplay.cs -- IsMprFeasible and the check that decides whether a candidate can pass its rules' stem-name and MPR-feature requirements.

Status: open; CI green on this head (the claude job is skipped).

After lexical lookup, Morpher drops an alternative before synthesis when a rule needs a stem name or MPR features that its root and trail cannot supply. No parse is lost, and analysis, lookup and the public API are untouched. In total work (analysis events plus synthesis steps) real grammars come out about break-even: Aweti +0.07%, Mbugwe 0.00%. Conformance and inflection-class fixtures lose slightly (-0.45%, -4.77%); a concord-heavy synthetic fixture gains 18%.

The unknown a reviewer arrives with is whether this is worth its code at break-even. It removes only work whose output was always discarded, but two fixtures show a net loss once replay work is counted. The question worth the time is whether the filter's soundness holds, because the gain on real grammars is small.

Where to look:

  • MPR bounds, and why they never reject a feasible candidate -- doc on LookupReplay.IsMprFeasible; LookupReplayMprTests, LookupReplayOutputsTests (Any/Overwrite groups, realizational and compounding outputs, parallel path).
  • The hook sits after the MaxAlternatives count, per alternative -- Morpher.IsPrunedByLookupReplay.
  • Pruned candidates no longer emit synthesis rule-not-applied trace events, so a trace consumer sees fewer events for the same parses.

Not here:

  • No cache and no public switch. Internal LookupReplayMode, LookupReplayVerdict and LookupReplayWork stay because the tests read per-alternative verdicts and work.
  • Template replay: tried and dropped on cost (Deferred, below).

Verification on this branch: dotnet test tests/SIL.Machine.Morphology.HermitCrab.Tests -c Release passed with 0 failures; dotnet csharpier check . and pwsh ./scripts/comment-hygiene.ps1 clean. The shadow-run and step-count measurements are in Evidence. Not run: the full ./local_check.sh --agent-strict.

Next: approve, or tell me to drop it given the break-even result on real grammars.


Reading this a year from now -- start here

The filter replays, per alternative from lexical lookup, only the checks that need no shapes: required stem names and MPR feature requirements across the rules of the path. If a rule on the path needs something the root plus the trail cannot supply, synthesis of that alternative cannot succeed, so it is skipped. Two MPR feature sets per grammar (realizational and compounding outputs) are built at construction, and the root's set is cloned only when a trail entry adds features.

The research record behind the measurements, including the dropped template replay, is #490 (open).

Evidence and caveats
  • Shadow run (filter only counts) over the conformance suite (Add a conformance suite for HermitCrab, with its adequacy argument #480, open, merged into a throwaway branch), five sampled grammars and two stress fixtures: 0 successful analyses under a rejected candidate; rejected-candidate steps equal steps saved.
  • Parse signatures on vs off are identical except the truncate-morphotactic* fixtures, which also differ between two runs with the filter off.
  • The two stress rows are synthetic fixtures kept out of the PR with the census harness. ClassGatedSlots_PruneMostWrongClassCandidatesAndParsesAreUnchanged reproduces the effect as a unit test.
Measured work, in steps

Failure-statistics harness (research/failure-reason-stats), one Release build, serial, 60 s per word, words aborted in any arm dropped. Replay work counts one unit per feature-set match, union or copy; units and steps are not shown to cost the same. Analysis events and lookup candidates are unchanged in every row. Net total-work is (steps saved - replay work) over synthesis steps plus analysis events, counting a unit as a step.

corpus synthesis steps off -> on saved replay work work / saved net total-work parses diff
conformance 8246 -> 8218 28 (0.3%) 189 6.75 -0.45% (gross 0.08%) 4/1476
indonesian 677 -> 677 0 (0.0%) 0 0.00 +0.00% (gross 0.00%) 0/70
amharic 1277 -> 1277 0 (0.0%) 0 0.00 +0.00% (gross 0.00%) 0/24
sena 1908 -> 1908 0 (0.0%) 0 0.00 +0.00% (gross 0.00%) 0/51
aweti 15940 -> 6873 9067 (56.9%) 8043 0.89 +0.07% (gross 0.58%) 0/99
mbugwe 130647 -> 115747 14900 (11.4%) 14887 1.00 +0.00% (gross 1.03%) 0/38
stress-mpr-inflection-classes 494 -> 278 216 (43.7%) 376 1.74 -4.77% (gross 6.45%) 0/52
stress-mpr-bantu-concord 3376 -> 382 2994 (88.7%) 1304 0.44 +17.98% (gross 31.85%) 0/24

The two stress rows are synthetic fixtures not in this PR.

Paths not taken

Template and final-state replay (kept at perf/hc-lookup-replay-templates): Mbugwe +1246 saved for 47,560 work, Aweti +2211 for 3728, Sena 118 for 1825; only Amharic paid (745 for 601). Dropped on cost.

Deferred, and what would unblock it
  • Template replay: needs a verdict reused across the roots of one lookup, which is a cache.

🤖 Generated with Claude Code


This change is Reviewable

After lexical lookup, each alternative is replayed against its root without
shapes and dropped when a rule's recorded allomorph requires MPR features the
root and earlier trail outputs cannot supply, excludes features the root
certainly has, or the rule requires a different stem name. The walk stops at
the first violation, runs only when some rule carries such a constraint, and
keeps nothing between candidates. Family roots, compounding entries and
reachable Overwrite groups disable the unsafe checks. Dropped candidates emit
no synthesis trace events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.07843% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.00%. Comparing base (c245476) to head (c6e2bbc).

Files with missing lines Patch % Lines
.../SIL.Machine.Morphology.HermitCrab/LookupReplay.cs 95.58% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #570      +/-   ##
==========================================
+ Coverage   74.57%   75.00%   +0.43%     
==========================================
  Files         453      454       +1     
  Lines       38342    38495     +153     
  Branches     5284     5317      +33     
==========================================
+ Hits        28593    28874     +281     
+ Misses       8583     8469     -114     
+ Partials     1166     1152      -14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

alternativeCount++;
if (MaxAlternatives > 0 && alternativeCount > MaxAlternatives)
throw new MaxAlternativesExceededException("MaxAlternatives exceeded");
if (IsPrunedByLookupReplay(alternative))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: F1. With tracing on, a pruned candidate leaves an empty trace node. LexicalLookup (Morpher.cs:466) has already emitted SynthesizeWord for it, but the RequiredStemName/RequiredMprFeatures events that explained the failure are gone. Try-a-Word users see a lookup that ends with no reason. LookupReplayMprTests.cs:42-43 confirms it: they count fewer synthesis events with pruning on under IsTracing = true. The precedent is AnalysisStratumRule.cs:142, which skips merging while tracing; skipping pruning the same way would keep traces unchanged. The parallel loop at line 415 needs the same fix.

if (upper == null)
{
work++;
upper = lower.Clone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F2. upper is a new clone of lower plus extra for every trail entry that has required features, even when extra is still the empty _realizationalOut. In that case lower already is the superset. Using it directly saves one allocation and one work unit per entry. That matters here because the PR's own table puts work/saved at about 1.0 for Aweti and Mbugwe.

[Test]
public void NullRecordedAllomorph_IsPrunedOnlyWhenEveryAllomorphFails()
{
// Guessed (null) allomorphs happen when the analysis came from a rule whose allomorph is unconstrained;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: F3. This comment gives the wrong cause for a null allomorph. AnalysisAffixProcessRule.cs:92 always records _rule.Allomorphs[i], so ParseWord never hands the replay a null allomorph for an affix rule. Nulls come only from GenerateWords/PermuteRules, and that path never calls IsFeasible. The null branches at LookupReplay.cs:145-155 and 167-177 are defensive code. Say that here, so nobody tunes them for a parse case that cannot happen.

@claude

claude Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor
  1. Verdict: approve with fixes. The superset and subset bounds hold against SynthesisAffixProcessRule, MprFeatureSet.AddOutput and Word.CheckBlocking as I traced them.
  2. Most important: F1 (Morpher.cs:376). Pruning while tracing removes the failure events that explain a candidate in the trace. Decide whether to skip pruning under IsTracing, as analysis merging already does.
  3. Counts: Critical 0, Important 1, Low 2.
  4. What I ran: gh pr checks 570 shows Build on ubuntu-22.04 and windows-latest passing and Create NuGet package pending. I traced each replay check against its synthesis gate by hand. F1 rests on the PR's own ParseCounting tests, not on a separate reproduction.
  5. Not verified: I did not re-run the measured-work table. The design question stays open: LookupReplay restates the synthesis gates instead of sharing a predicate with them, so a future synthesis constraint can silently invalidate the proofs. The internal LookupReplayMode gives consumers no way to turn pruning off.

Public API, target framework, package, machine.py parity: None verified. Every new member is internal.

Findings: F1 new, F2 new, F3 new.

Reviewed at c6e2bbc

@johnml1135

Copy link
Copy Markdown
Collaborator Author

Closing. It breaks even on real grammars (Aweti +0.07%) and loses on two fixtures, which does not justify 785 lines. The branch stays if a grammar ever shows a real gain.

@johnml1135 johnml1135 closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants